web/src/routes/image/[id]/+page.svelte (view raw)
1<script lang="ts">
2 import { onMount } from 'svelte';
3 import { getImage, addTags, removeTags, suggestTags, imageDownloadUrl, type ImageDetail } from '$lib/api';
4
5 let { params } = $props();
6 let id = $derived(parseInt(params.id));
7
8 let img = $state<ImageDetail | null>(null);
9 let loading = $state(true);
10 let error = $state('');
11
12 let tagInput = $state('');
13 let suggestions = $state<string[]>([]);
14 let showSuggestions = $state(false);
15 let tagError = $state('');
16
17 onMount(async () => {
18 try {
19 img = await getImage(id);
20 } catch (err) {
21 error = err instanceof Error ? err.message : 'Failed to load image';
22 } finally {
23 loading = false;
24 }
25 });
26
27 function formatSize(bytes: number): string {
28 if (bytes < 1024) return bytes + ' B';
29 if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
30 return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
31 }
32
33 function formatDate(s: string): string {
34 return new Date(s).toLocaleString();
35 }
36
37 async function onTagInput() {
38 const q = tagInput.split(',').pop()?.trim() || '';
39 if (q.length < 1) {
40 suggestions = [];
41 showSuggestions = false;
42 return;
43 }
44 try {
45 const result = await suggestTags(q);
46 suggestions = result.map((t) => t.tag);
47 showSuggestions = suggestions.length > 0;
48 } catch {
49 suggestions = [];
50 showSuggestions = false;
51 }
52 }
53
54 function selectSuggestion(s: string) {
55 const parts = tagInput.split(',').slice(0, -1);
56 parts.push(s);
57 tagInput = parts.join(', ') + ', ';
58 showSuggestions = false;
59 }
60
61 async function handleAddTags() {
62 if (!img) return;
63 const tags = tagInput.split(',').map((t) => t.trim()).filter(Boolean);
64 if (tags.length === 0) return;
65 tagError = '';
66 try {
67 const result = await addTags(img.id, tags);
68 img.tags_detail = result.tags_detail;
69 img.tags = result.tags_detail.map((t) => t.tag);
70 tagInput = '';
71 } catch (err) {
72 tagError = err instanceof Error ? err.message : 'Failed to add tags';
73 }
74 }
75
76 async function handleRemoveTags() {
77 if (!img) return;
78 const tags = tagInput.split(',').map((t) => t.trim()).filter(Boolean);
79 if (tags.length === 0) return;
80 tagError = '';
81 try {
82 const result = await removeTags(img.id, tags);
83 img.tags_detail = result.tags_detail;
84 img.tags = result.tags_detail.map((t) => t.tag);
85 tagInput = '';
86 } catch (err) {
87 tagError = err instanceof Error ? err.message : 'Failed to remove tags';
88 }
89 }
90</script>
91
92{#if loading}
93 <p class="state-msg">Loading...</p>
94{:else if error}
95 <p class="state-msg error">{error}</p>
96{:else if img}
97 <div class="page-head">
98 <h1>{img.filename}</h1>
99 <div class="head-line"></div>
100 </div>
101
102 <div class="layout">
103 <div class="preview">
104 {#if img.thumbnail_path}
105 <img src={imageDownloadUrl(img.id)} alt={img.filename} />
106 {:else}
107 <div class="no-thumb">NO THUMBNAIL</div>
108 {/if}
109 </div>
110
111 <div class="meta">
112 <div class="meta-block uploader-block">
113 <span class="meta-label">UPLOADED BY</span>
114 <div class="uploader-row">
115 {#if img.avatar_url}
116 <img src={img.avatar_url} alt="" class="avatar-img" />
117 {/if}
118 <span class="meta-value">{img.uploader_username}</span>
119 </div>
120 </div>
121 <div class="meta-block">
122 <span class="meta-label">ID</span>
123 <span class="meta-value">#{img.id}</span>
124 </div>
125 <div class="meta-block">
126 <span class="meta-label">DIMENSIONS</span>
127 <span class="meta-value">{img.width} × {img.height}</span>
128 </div>
129 <div class="meta-block">
130 <span class="meta-label">FILE SIZE</span>
131 <span class="meta-value">{formatSize(img.file_size)}</span>
132 </div>
133 <div class="meta-block">
134 <span class="meta-label">TYPE</span>
135 <span class="meta-value">{img.mime_type}</span>
136 </div>
137 <div class="meta-block">
138 <span class="meta-label">UPLOADED</span>
139 <span class="meta-value">{formatDate(img.created_at)}</span>
140 </div>
141 <div class="meta-block tags-block">
142 <span class="meta-label">TAGS</span>
143 <div class="tag-pills">
144 {#if img.tags_detail.length > 0}
145 {#each img.tags_detail as t (t.tag)}
146 <a href="/browse?tag={encodeURIComponent(t.tag)}" class="tag-pill" title="Used {t.usage_count} time{t.usage_count === 1 ? '' : 's'}">
147 <span class="tag-name">{t.tag}</span>
148 <span class="tag-count">{t.usage_count}</span>
149 </a>
150 {/each}
151 {:else}
152 <span class="none">none</span>
153 {/if}
154 </div>
155 </div>
156
157 {#if img.is_owner}
158 <div class="meta-block edit-tags-block">
159 <span class="meta-label">EDIT TAGS</span>
160 <div class="tag-input-row">
161 <div class="tag-wrapper">
162 <input type="text" placeholder="funny, meme, cat" bind:value={tagInput}
163 oninput={onTagInput}
164 onblur={() => setTimeout(() => { showSuggestions = false; }, 200)}
165 />
166 {#if showSuggestions}
167 <ul class="suggestions">
168 {#each suggestions as s}
169 <li role="button" tabindex="0" onmousedown={() => selectSuggestion(s)}>{s}</li>
170 {/each}
171 </ul>
172 {/if}
173 </div>
174 <button class="tag-btn add" onclick={handleAddTags}>ADD</button>
175 <button class="tag-btn remove" onclick={handleRemoveTags}>REMOVE</button>
176 </div>
177 {#if tagError}
178 <span class="tag-err">{tagError}</span>
179 {/if}
180 </div>
181 {/if}
182
183 <a href={imageDownloadUrl(img.id)} class="btn" download={img.filename}>
184 DOWNLOAD ORIGINAL
185 </a>
186 </div>
187 </div>
188
189 <p class="back"><a href="/browse">← BACK TO BROWSE</a></p>
190{/if}
191
192<style>
193 .state-msg {
194 padding: 2rem 0;
195 font-size: 1.1rem;
196 color: #888;
197 }
198 .error {
199 color: #c62828;
200 }
201
202 .page-head {
203 margin-bottom: 1.5rem;
204 }
205 .page-head h1 {
206 word-break: break-all;
207 }
208 .head-line {
209 height: 3px;
210 background: #c62828;
211 width: 3rem;
212 margin-top: 0.4rem;
213 }
214
215 .layout {
216 display: flex;
217 gap: 2rem;
218 flex-wrap: wrap;
219 }
220 .preview {
221 flex: 1;
222 min-width: 300px;
223 background: #1a1a1a;
224 display: flex;
225 align-items: center;
226 justify-content: center;
227 }
228 .preview img {
229 display: block;
230 width: 100%;
231 height: auto;
232 max-height: 70vh;
233 object-fit: contain;
234 }
235 .no-thumb {
236 padding: 4rem 2rem;
237 color: #555;
238 font-family: 'Oswald', sans-serif;
239 font-weight: 500;
240 font-size: 0.9rem;
241 letter-spacing: 0.12em;
242 }
243
244 .meta {
245 width: 260px;
246 flex-shrink: 0;
247 display: flex;
248 flex-direction: column;
249 gap: 1rem;
250 }
251 .meta-block {
252 display: flex;
253 flex-direction: column;
254 gap: 0.15rem;
255 }
256 .meta-label {
257 font-family: 'Oswald', sans-serif;
258 font-weight: 500;
259 font-size: 0.7rem;
260 letter-spacing: 0.15em;
261 color: #999;
262 }
263 .meta-value {
264 font-family: 'Inter', sans-serif;
265 font-size: 0.9rem;
266 font-weight: 600;
267 color: #1a1a1a;
268 }
269
270 .uploader-row {
271 display: flex;
272 align-items: center;
273 gap: 0.4rem;
274 }
275 .avatar-img {
276 width: 1.5rem;
277 height: 1.5rem;
278 object-fit: cover;
279 flex-shrink: 0;
280 }
281
282 .tags-block {
283 flex: 1;
284 }
285 .tag-pills {
286 display: flex;
287 flex-wrap: wrap;
288 gap: 0.35rem;
289 }
290 .tag-pill {
291 display: inline-flex;
292 align-items: center;
293 gap: 0.3rem;
294 background: #1a1a1a;
295 padding: 0.25rem 0.5rem 0.25rem 0.65rem;
296 font-family: 'Inter', sans-serif;
297 font-size: 0.78rem;
298 font-weight: 500;
299 color: #eee;
300 text-decoration: none;
301 transition: background 0.15s;
302 }
303 .tag-pill:hover {
304 background: #c62828;
305 text-decoration: none;
306 }
307 .tag-name {
308 font-weight: 600;
309 }
310 .tag-count {
311 background: rgba(255, 255, 255, 0.12);
312 padding: 0.05rem 0.35rem;
313 font-size: 0.68rem;
314 font-weight: 600;
315 color: rgba(255, 255, 255, 0.65);
316 min-width: 1.1rem;
317 text-align: center;
318 }
319 .tag-pill:hover .tag-count {
320 background: rgba(0, 0, 0, 0.2);
321 }
322 .none {
323 color: #bbb;
324 font-size: 0.85rem;
325 }
326
327 .btn {
328 display: inline-flex;
329 align-items: center;
330 align-self: flex-start;
331 padding: 0.55rem 1.25rem;
332 background: #c62828;
333 color: #fff;
334 border: none;
335 font-family: 'Oswald', sans-serif;
336 font-weight: 500;
337 font-size: 0.8rem;
338 letter-spacing: 0.1em;
339 text-decoration: none;
340 text-transform: uppercase;
341 cursor: pointer;
342 transition: background 0.15s;
343 margin-top: 0.5rem;
344 }
345 .btn:hover {
346 background: #b71c1c;
347 text-decoration: none;
348 }
349
350 .back {
351 margin-top: 2rem;
352 }
353 .back a {
354 font-family: 'Oswald', sans-serif;
355 font-weight: 500;
356 font-size: 0.85rem;
357 letter-spacing: 0.1em;
358 }
359
360 .edit-tags-block {
361 border-top: 2px solid #1a1a1a;
362 padding-top: 0.75rem;
363 }
364 .tag-input-row {
365 display: flex;
366 gap: 0.35rem;
367 align-items: flex-start;
368 }
369 .tag-wrapper {
370 flex: 1;
371 position: relative;
372 min-width: 0;
373 }
374 .tag-wrapper input[type="text"] {
375 width: 100%;
376 padding: 0.4rem 0.5rem;
377 border: 2px solid #1a1a1a;
378 border-radius: 0;
379 font-family: 'Inter', sans-serif;
380 font-size: 0.78rem;
381 font-weight: 500;
382 background: #fff;
383 box-sizing: border-box;
384 }
385 .tag-wrapper input[type="text"]:focus {
386 outline: none;
387 border-color: #c62828;
388 }
389 .suggestions {
390 position: absolute;
391 top: 100%;
392 left: 0;
393 right: 0;
394 background: #fff;
395 border: 2px solid #1a1a1a;
396 border-top: none;
397 list-style: none;
398 margin: 0;
399 padding: 0;
400 z-index: 10;
401 max-height: 130px;
402 overflow-y: auto;
403 }
404 .suggestions li {
405 padding: 0.35rem 0.5rem;
406 cursor: pointer;
407 font-family: 'Inter', sans-serif;
408 font-size: 0.78rem;
409 font-weight: 500;
410 }
411 .suggestions li:hover {
412 background: #f0f0f0;
413 }
414 .tag-btn {
415 padding: 0.4rem 0.65rem;
416 border: none;
417 font-family: 'Oswald', sans-serif;
418 font-weight: 500;
419 font-size: 0.72rem;
420 letter-spacing: 0.08em;
421 cursor: pointer;
422 white-space: nowrap;
423 transition: background 0.15s;
424 }
425 .tag-btn.add {
426 background: #1a1a1a;
427 color: #fff;
428 }
429 .tag-btn.add:hover {
430 background: #2e7d32;
431 }
432 .tag-btn.remove {
433 background: #1a1a1a;
434 color: #fff;
435 }
436 .tag-btn.remove:hover {
437 background: #c62828;
438 }
439 .tag-err {
440 display: block;
441 margin-top: 0.3rem;
442 color: #c62828;
443 font-family: 'Inter', sans-serif;
444 font-size: 0.72rem;
445 font-weight: 600;
446 }
447</style>